Skip to content

fix(triggers,spec,service-automation)!: a time-triggered flow declares its acting organization and the run executes as it - #17334

Merged
os-bill merged 26 commits into
mainfrom
claude/issue-16659-schedule-trigger-acting-organization-r2
Sep 10, 2026
Merged

fix(triggers,spec,service-automation)!: a time-triggered flow declares its acting organization and the run executes as it#17334
os-bill merged 26 commits into
mainfrom
claude/issue-16659-schedule-trigger-acting-organization-r2

Conversation

@os-tesla

@os-tesla os-tesla commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #16659

Clause-②: yes — packages/spec/src/automation/index.ts gains re-export lines and the generated surface gains five rows: SCHEDULE_ORGANIZATION_KEY, ScheduleOrganizationSchema, the ScheduleOrganization type, resolveScheduleOrganization and describeMissingScheduleOrganization. New exported symbols on a published entry point. One more published symbol lands on a different package: @objectstack/lint re-exports FLOW_SCHEDULE_ORGANIZATION_MISSING. ⚠️ It was six until this round — findScheduleOrganizationNearMissInConfig is folded into the sentence that was its only consumer (A1 below), so the count above is the one that freezes.

⚠️ This pull request is a re-creation, and its branch is a re-push. The original (#17126, branch claude/issue-16659-schedule-trigger-acting-organization) was removed when the os-trump account was suspended. That PR is now unreachable (404) yet still holds its branch, so GitHub refuses a second PR on it — hence the -r2 name.

The first 14 commits are byte-identical to the original patched head b998f58f3832d3f8f952172bbd9b28f668f18ed9. ⛔ No rebase, no amend, no squash; this round is append-only on top of it.

What was lost and is NOT reconstructed here: the original PR body, and the CONTRACT_REVIEW_TIER review thread that returned the first head CHANGES REQUIRED. This PR has had no at-tier review at all as far as the record now shows, so it needs a fresh one rather than a re-review.


⭐ Round 3 (domain:spec seat) — the merge, the two must-fix items, and the advisories

Written from session session_01MkQhmuuJAVDjmeWNixwDDH; the contract review this round
discharges is comment 5619271717 on this PR, and the card's live Claim: is
5619338692. ⛔ Fenced to the merge and that review's findings — nothing about the design
is this round's to change, and F7 plus the near-miss vocabulary gap stay out.

1. The merge — per-path preservation, proved

mergeable_state was dirty. Merged (⛔ never rebased) through
scripts/pm/os-regen-merge.sh, with the risk list read from .gitattributes at merge
time
: 18 routed patterns. Against merge base e669e5eee, 8 routed paths were moved
by this branch and 46 by main — and exactly one by both:
content/docs/references/index.mdx. That is the only path where the merge=os-regen
driver could drop a side, and it did: after the merge the file carried this branch's
schedule-organization row and had silently lost main's marketplace section, exit 0,
no conflict marker. Step 2 restored main's side; step 4 regenerated the whole chain on the
merged tree.

The proof, per routed path, each reading paired with a lit control (a path or marker
known to have moved, reading non-zero) and a dark control (a fabricated path or marker,
reading zero):

reading result lit control dark control
contested path carries both sides branch row schedule-organization = 1 · main section Marketplace Protocol = 2 · main's removal Cloud Protocol = 0 Automation Protocol = 2 Zzz Fabricated Protocol = 0
the 45 routed paths main moved and the branch did not byte-identical to origin/maingit diff --name-only origin/main over that set is empty the same command over api-surface/automation.json lists it the same command over a fabricated api-surface/zzz-fabricated.json lists 0
the 7 routed paths the branch moved and main did not every one still carries its branch content (ScheduleOrganization / schedule-organization all non-zero) api-surface/api.json reads 0 for the same marker ZzzFabricatedSymbol in api-surface/automation.json reads 0

Both intents stack arithmetically in the regenerated index: 198 pages + 1 (this branch) − 11
(cloud retired on main) + 4 (marketplace arrived) = 192, which is what the file says.

Two non-generated conflicts were hand-resolved as semantic merges, both intents stacked:

  • packages/spec/llms.txt — 205 + 1 − 11 + 4 = 199, and the merged table sums to 199.
  • packages/spec/src/type-alias-convention.pin.test.ts — 811 − 30 + 1 + 1 = 783,
    recomputed from the merged file by the test's own predicate rather than from either
    side's arithmetic. ⚠️ The text merge also took both sides' NEW pin with no conflict
    marker
    , leaving two declarations sharing the id Iso871 (main's EnvironmentTypeSchema
    and this branch's ScheduleOrganizationSchema) — a duplicate-identifier error behind a
    merge that read clean. This branch's is renumbered Iso872; ids are claims about pins,
    not positions.

2. M1 — the ADR-0087 disposition was wrong on substance. Fixed.

not-required (no-migration-prescription)registered schedule-flow-acting-organization-required, one new entry under
packages/spec/src/migrations/entries/semantic/, gen:migration-registry run. D3 covers
exactly this case, and the changeset's body is a prescription in D3's sense. Direct
precedent: rest-requireauth-default-flip (protocol 12) — behaviour-only, no shape moved,
a deployment judgement no transform can make, registered anyway.

⚠️ Registered under protocol 18, not 17 — the dispatch said 17 and the tree says
otherwise. registry.ts's step18 header states the convention in as many words:
"v17.0.0 was cut before these narrowings landed, so their migration prescriptions belong
to the NEXT major … The enforcement itself ships earlier on the 17.x line (launch-window
convention: accept-set narrowings ride minor releases); this step is where migrate meta
users are told, at the major boundary where they look."
The same-shaped sibling
18.cloud-subpath-retired (#16325) is filed there too.

node scripts/check-adr-0087-registration.mjs --base origin/main, run post-commit
because the gate reads changesets from git: exit 0, and it names the entry as new
here
— i.e. registered by this PR, not claimed against a pre-existing one.

⚠️ Worth one line for the next author: the adr-0087: registered marker's remainder is
parsed as a comma-separated id list, not as prose. Rationale that rides inside it reads
as several hundred nonexistent migration ids. The reasoning lives in the body instead.

3. M2 — the banner's single-organization sentence was false on driver-memory. Fixed, and it is NOT only a sentence.

Verified by running the driver's own exported predicate rather than a re-implementation
of it (assertCallNotTenantScoped, packages/drivers/driver-memory/src/memory-tenancy-guard.ts),
over all twelve doors it guards:

the call the engine hands the driver result
scoped — tenantId present (the shape a DECLARED flow produces) 12 of 12 refused, MEMORY_MULTI_TENANT_UNSUPPORTED
unscoped — no tenantId (the shape a job tick produced before this change) 0 of 12 refused, 12 served

The guard has no organization-count limb at all: if (tenantId === undefined && tenantIds.length === 0) return;
and a throw otherwise. So a single-organization install is not exempt, and the banner's
"That install loses nothing at run time" was false there. The sentence is now
driver-conditional and names the refusal.

And the seat's question, answered: it is not only a sentence. On driver-memory a
time-triggered flow that touches genuinely per-organization data has no legal
configuration
— declare an organization and every data node is refused per call; declare
none and the flow is not armed. The one configuration that is served, declaring the swept
objects platform-global (tenancy: { enabled: false }, ADR-0066), is the one the guard's
own message says must not be reached for on data that really is per-organization. That is a
functional gap on a supported driver wearing a documentation defect's clothes. ⛔ This round
does not fix it — it is out of the fence, and the remedy is a design decision (does a
time-triggered flow on a store with no tenant isolation get an explicit exemption, or is
driver-memory declared unsupported for time-triggered data flows?). Reported and stopped
at that boundary; the banner and the migration entry both now say the gap exists rather
than implying it does not.

4. A1 — one export folded. Five, not six.

findScheduleOrganizationNearMissInConfig is no longer published. Both callers
(validate-flow-trigger-readiness.ts, schedule-trigger.ts) ran it only to hand the answer
straight to describeMissingScheduleOrganization on the next line, so the scan moved inside
that function, which now takes describeMissingScheduleOrganization(flowName, { kind, config })
and consults the vocabulary itself. api-surface / export-origins / declaration-map and
the reference page regenerated; the symbol reads 0 across all four generated surfaces,
with describeMissingScheduleOrganization still present in two of them as the lit control.
⭐ A minor freezes what it publishes, and removing an export later is breaking where adding
one is not.

5. A2 — the name is KEPT, and the decision is a measurement, not a preference.

The question was whether time_relative is described as a "schedule" anywhere
authoritative. It is, in the place that governs authoring:

  • FlowSchema.type is z.enum(['autolaunched', 'record_change', 'schedule', 'screen', 'api'])
    — there is no time_relative flow type. A time-relative sweep is authored as
    type: 'schedule'; the docs say so in as many words ("a schedule flow whose start
    node declares a timeRelative descriptor"
    ) and the renewalReminder example in
    content/docs/automation/flows.mdx literally sets type: 'schedule'.
  • TimeRelativeTriggerSchema's own opening line: "A time_relative trigger sweeps an
    object on a schedule (daily by default)"
    .
  • resolveFlowTriggerKind's precedence note: a sweep "ALSO carries a schedule cadence
    (its sweep interval)"
    , and it distinguishes a sweep from "a plain schedule flow" — a
    split inside the schedule family, not out of it.
  • ADR text: zero mentions of time_relative / timeRelative across docs/adr/*.md
    (lit control: schedule matches many ADRs; dark control: a fabricated term matches none).
    The ADRs say nothing either way, so the enum and the docs are the authority.

⇒ Every flow this key applies to declares type: 'schedule'. ScheduleOrganization… is
accurate for both subjects and is kept; FlowActingOrganization… would have been a rename
away from the authorable vocabulary. Recorded in the module docblock so it is not
re-litigated after the freeze.

6. A4 / A5 / A6 — all three verified, all three now named

Each was checked against the tree before being written down, and each is in the banner
and in the migration entry's replacement:

  • A4 — a NULL-organization row fans out N-fold. sql-driver.ts scopes with
    (field = :tenantId OR field IS NULL), and this PR's own negative-control fixture
    selects its organization_id: null row (c3) under scope — the asserted set is
    ['c1','c2','c3']. ⇒ after "one flow per organization", every such row is matched once
    per flow: N runs, N notifications, each acting as a different organization. Survives.
  • A5 — the current window's dispatch claims are abandoned.
    scheduleDispatchKey returns `schedule:${flowName}:${window.startedAt}` and the
    sweep's key is `time-relative:${flowName}:${scope}:${id}`. Both embed the flow name,
    so N differently-named flows claim under N different keys. Survives.
  • A6 — a run suspended before the upgrade is not retroactively confined.
    suspended-run-store.ts rehydrates with
    parseJson(row.context_json, {}); a row written before this change carries no tenantId,
    and nothing back-fills it. Survives (not a regression — it is how the run already ran —
    but the banner implied confinement without the carve-out).

7. Verification at head 18c0f6b44b

suite result
@objectstack/spec test 470 files / 13238 passed — includes type-alias-convention.pin.test.ts"still declares all 783 isomorphic pins"
@objectstack/lint test 103 files / 3724 passed
@objectstack/trigger-schedule test 5 files / 105 passed
typecheck, all three exit 0
pnpm --filter @objectstack/spec check:generated exit 0 — 15 of 15 artifacts current
check:react-declaration-parity exit 0, with the documented MANIFEST=$PWD/sdui.manifest.json invocation — measured, ⛔ not recorded as unrunnable
pnpm lint (repo-wide eslint . --no-inline-config) exit 0 in 110s — the full run, ⛔ not a narrowing
check-clause2-carriers --pair 17334 exit 0 — the declaration is readable in the fixed spelling and both carriers agree

Gates. dispatch-gates derives 119 families for this change set; all 119 were run
with exit codes recorded and reconciled through --ran: 117 run / 2 NOT MEASURED / 0
UNRUN
. The two are check:dual-build-cjs-loads and check:type-check-debt, both at
exit 3 — PREREQUISITE NOT MET (each reads a complete repo build). ⛔ Neither is a pass
and neither is a finding; both are hosted by CI, which builds first.
check:skill-examples refused the same way on the first attempt and was then genuinely
measured after building @objectstack/client-react: exit 0, 258 prose examples across 3
surfaces.

⚠️ origin/main moved again while this round ran, so dispatch-gates prints a STALE
TREE notice against the newest tip. The derivation above is against the tree at
18c0f6b44b; CI re-derives on the head it tests.

Still owed, and not this round's to take: the contract-review verdict must be re-taken
on the head that lands — this round moved it — and needs:contract-review stays on both
carriers until the seat clears it.

⚠️ Not backfilled, deliberately: the two remaining is being re-filed source comments
(examples/app-showcase/src/automation/flows/index.ts,
examples/app-todo/src/flows/task.flow.ts) still carry no number. The backfill was fenced
as opportunistic — only if you are touching those files anyway — and this round touches
neither. The changeset's copy of the sentence is updated to name #17396, because
the changeset was already being edited.


The ruling

Maintainer, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」

⭐ This round: the ruling's other half

The first head declared the acting organization and put it on the RUN. The adversarial review on #16659 (comment 5615359989) found the complementary half missing, and the maintainer ruled option A — complete the ruling. The time_relative sweep was still selecting across every organization:

// before — time-relative-trigger.ts, the sweep's own query
await engine.find(desc.object, { where, limit: maxRecords, context: { isSystem: true } });

isSystem is authorization; tenantId is tenancy. Passing only the first made this a cross-organization scheduled task with a declaration papering over it: a sweep declared for org A matched rows in org B and launched one run per match, each stamped A. Downstream that is worse than the defect the card opened on:

  • the run is scoped to A, so its update_record on a B row matches nothing — silently;
  • notify posts into A's inbox about B's record — a cross-tenant disclosure that was previously refused outright, because an org-less run could write nowhere;
  • the history row is stamped SUBJECT-first, so it lands under B while the inbox rows sit under A. One run, three organizations' opinions about whose it was.

The fix, and why this mechanism and not a predicate

The declared organization now rides the sweep's own query as context.tenantId — the same ExecutionContext axis the run already uses. Engine.buildDriverOptions turns it into DriverOptions.tenantId and the driver scopes the read. Both members are keys EngineQueryOptions.context already accepts (ExecutionContextSchema.partial()), so naming tenantId on the trigger's structural data-engine slice widens no contract; it declares the slice it uses.

Not a hand-built organization_id predicate on where. That would be a second implementation of tenancy inside a trigger: it would hardcode a column the object is free to rename (tenancy.tenantField), select nothing on a platform-global object that has no such column, break a federated object outright, and — worst — read as a scoped query to a driver that never learned the caller wanted scoping, so a driver with no isolation would answer it silently instead of refusing it.

Both new silences are closed, because that failure mode is this card's own subject

  • A store that cannot honour the scope refuses the call. driver-memory implements no row-level tenant isolation and refuses anything handed a tenant scope (MEMORY_MULTI_TENANT_UNSUPPORTED, [finding] The InMemory driver silently ignores the engine's tenant scope for objects that OMIT a tenancy block — its guard only refuses an explicit enabled: true, so memory-driver runs show cross-organization rows a SQL driver refuses #16589). The sweep's failure is now logged at error rather than warn — stderr survives the CLI's boot-quiet window, and "this sweep can no longer see anything" became a reachable state the moment the query was scoped.
  • On a tenancy.enabled: false (ADR-0066) or federated (ADR-0015) object the engine drops the scope, so the declaration cannot narrow that sweep at all — it still selects across every organization while its runs act as the declared one. The trigger says so at bind, at warn, naming the object. ⛔ It does not pretend the flow is contained. That branch is a disclosure only: it never changes which rows come back, so a drift from the engine's predicate costs a wrong warning, never a wrong row.
  • The bind line now names the acting organization, so a sweep's reach is readable from the boot log.

The rest of the review's blocking findings

  • F1 / F3 — the changeset said something false, twice. It claimed "Nothing that was already delivering stops delivering" and, three paragraphs later, that on a single-organization install such a flow "was working" (system-write-organization.ts:268-273 confirms the second). The banner now states the narrowing in two places — the bind-time accept set AND the run-time data plane, which no sentence in it previously mentioned — and carries the migration consequence an operator reads release notes for: a deployment running ONE time-triggered flow across ALL organizations must now declare one flow per organization.
  • F4 — authoring learns the rule. validate-flow-trigger-readiness.ts held zero occurrences of organization, against engine.ts:21-27's own invariant. It now carries flow-schedule-organization-missing, re-implementing no judgement: resolveFlowTriggerKind says which flows owe the key, resolveScheduleOrganization says whether one was declared, describeMissingScheduleOrganization writes the sentence. Severity warning, measured not argued — see below.
  • F5 — the shipped examples. All four package-shipped time-triggered flows stop firing and now say so where an author meets them.
  • F8 — a comment this change made false (suspended-run-store.ts: "a plain scheduled sweep has neither and keeps NULL") now says what happens, keeping the SUBJECT-first precedence it documents.
  • F9 — sync and regenerate. Merged origin/main through scripts/pm/os-regen-merge.sh (⛔ no rebase). The one content conflict, type-alias-convention.pin.test.ts, was a semantic merge of two stacking intents — main's −4 (ui#6206-B, 815 → 811) and this branch's +1 (Iso871) — resolved to 812, recomputed from the file rather than by arithmetic, with both narratives kept. Generated artifacts were then regenerated wholesale on the merged tree: totals 198/1583 → 199/1584, automation 13/73 → 14/74.

F7 was left alone, and so was the whole bind-time half: the review found it well built and genuinely pinned, and this round does not touch it. F6 is the PM's.

The severity decision was measured, not asserted

On the never-fire family's own criterion (#5762is this stack enough to know the flow is dead?) the new lint id belongs at error. It is held at warning because of the corpus, and that was run rather than reasoned: with the severity flipped to error, objectstack build on examples/app-showcase fails, naming showcase_task_due_reminder and showcase_scheduled_digest. Neither can be repaired by authoring — the only legal value is a sys_organization.id minted per install at runtime, and ⛔ a placeholder id is strictly worse than the omission. Promoting the id is a consequence of the open maintainer decision about package-shipped time-triggered flows, not a lint choice. The probe was restored byte-identically (blob == pre-probe blob) and both example builds pass at warning, flagging exactly the four shipped flows.

⚠️ The card that carried that decision — #17150 — is 404, destroyed with the suspended account, and is being re-filed by the PM. This PR does not invent a number for it: the four in-source comments and the changeset carry the record instead, and the number is owed once the new card exists (grep is being re-filed).

Verification

The differential control

⛔ A test that only proves "A's rows are found" does not distinguish the fix from the defect — the defect found them too, alongside B's. So the discriminating number is the count of launched runs.

packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts boots the real automation + messaging + ObjectQL stack with two organizations, one matching row in A and two in B, and a time_relative flow declared for A. On sqlite-wasm: exactly 1 run launches (3 is the unfixed reading), update_record lands on A's row and no other, and no sys_notification names a B record. On memory: the scoped sweep is refused and reported at error, with a paired control proving the refusal is about the scope and not about the fixture.

Negative control — measured, not argued

Two legs over one fixture: the sweep as it ships (scoped) and the same query with isSystem only, which is the pre-fix sweep byte for byte. Asserted equal. The fixture carries a NULL-organization row on purpose — the driver's scope is org = :tenant OR org IS NULL, so a platform row stays visible to a scoped read, and that is part of "identical" rather than an exception to it. Non-vacuous in both directions: the selected set is asserted to be the three rows, and leg A is asserted to have really asked for a scope, so this pin reddens under the same ablation as the differential instead of being one that can never fail. Its opposite limb is the DIFFERENTIAL above: add a second organization and the two legs must diverge.

What a single-organization install does feel is the bind-time half — the flow is unarmed until it declares one line — and the changeset now says so instead of denying it.

Ablation — direction predicted in writing first, then measured

Reverting the sweep's context to the pre-fix bytes, in one shell under trap … EXIT INT TERM, with the mutation proven on disk before either run (fixed spelling 1 → 0, ablated spelling 0 → 1, git diff --numstat = 1/1):

suite ablated
dogfood sweep-scope 3 failed / 4 passed — DIFFERENTIAL (['a1','b1','b2']-shaped: 3 runs), the notification disclosure, and the memory refusal
trigger-schedule unit 4 failed / 100 passed

As predicted, the run acts on the DECLARED organization's record stayed green under ablation — the run is scoped either way — which is exactly why the run count, not the write, carries the proof. One unit failure beyond the three enumerated: the unit twin of the memory-refusal pin, predicted for dogfood and omitted from the unit list.

Restoration proven by state, not by exit code: git diff HEAD empty, git hash-object == the HEAD blob, git status --porcelain clean. Both suites resolve @objectstack/trigger-schedule to source (dogfood's vitest alias + tsconfig paths; the pair is absent from KNOWN_UNALIASED_TEST_IMPORTS), so no dist/ could retain the mutation.

Full suites at the round-2 head 5fb332bdc (superseded — see Round 3 §7 for head 18c0f6b44b)

⛔ Reported as NEW numbers, not as confirmations: nothing on this branch had been run against that day's main at all.

suite result
@objectstack/spec test 472 files / 13279 passed — includes type-alias-convention.pin.test.ts"still declares all 812 isomorphic pins"
@objectstack/service-automation test 128 files / 1521 passed
@objectstack/trigger-schedule test 5 files / 105 passed (99 → 105: six new pins)
@objectstack/lint test 103 files / 3714 passed
@objectstack/dogfood test 137 passed + 1 skipped files / 1087 passed + 3 skipped
typecheck, all five exit 0
pnpm lint (repo-wide eslint . --no-inline-config) exit 0, zero output — the full run, not a narrowing
objectstack build, showcase + todo exit 0, the new lint id warning on exactly the four shipped flows

Gates. dispatch-gates derived 117 families for this change set; all 117 were run with exit codes recorded and reconciled via --ran: 116 run / 1 NOT-MEASURED / 0 UNRUN. The one is check:dual-build-cjs-loads at exit 3 — PREREQUISITE NOT MET (it reads a complete pnpm build); ⛔ that is not a pass. It is hosted by ci.yml's build-core job, and Build Core is green on this head, so the gate is measured — by CI, not locally. Chasing it locally package-by-package produced a different finding set on every run against a partial tree, naming only packages this diff never touches: a fact about build state, never about the diff.

Scope

Against today's origin/main (merge base e669e5eee): 42 files, +3045 / −53. The patch round itself is 15 files, +1273 / −65 on top of the original head — trigger-schedule's sweep and its pins, the dogfood differential control and its fixture, the lint rule and its suite, the changeset, the flows guide, the four example-flow comments, and one stale comment in suspended-run-store.

⛔ Out of scope and untouched: the bind-time refusal, its pins, and the six spec exports the original head added.


Generated by Claude Code

… its acting organization and runs as it

A `type: 'schedule'` flow and a `time_relative` sweep launch their runs from a
job tick, which carries no identity, so `AutomationContext.tenantId` was never
set. On an install holding more than one `sys_organization` every tenant-scoped
write beneath the run was then refused by the #8844 guard — the inbox rows a
`notify` node emits and the `sys_automation_run` history row — while the tick
still summarised itself healthy.

- `@objectstack/spec` declares the start-node `config.organization` key, its
  value schema, the two kinds that owe it, the near-miss spellings an author
  reaches for, and the one refusal sentence every enforcement point says.
- The engine lifts the declaration onto the schedule / time_relative binding.
- Both triggers REFUSE to bind a flow that declares none, naming it at `error`,
  and thread the declared organization onto the run as `tenantId`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
… differential controls

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…reference total

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…gfood pin

The pin's subject is the trigger's own run-context construction, so a dist
merely behind would run it green against the old one — the exact shape this
card is about. check:test-source-alias and check:type-source-resolution both
name this as the required spelling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…ine records it

`FlowTrigger.start()` is `void`, so logging the refusal and returning left the
engine free to run `boundFlowTriggers.set(...)` and log "bound" one line later:
`getFlowRuntimeStates()` answered `bound: true`, `getTriggerBindingAudit()`
skipped the flow, and the CLI startup summary said every triggered flow was
wired. Both time triggers now throw the same sentence they log, which is the
engine's designed catch path — the flow is never marked bound and the audit
lists it with `binding failed — see earlier warnings`.

Also folds the trigger's inline near-miss scan back into `packages/spec`
(`findScheduleOrganizationNearMissInConfig`, which takes the start-node config
a trigger actually holds), drops the two consumer-less exports
(`TIME_TRIGGERED_FLOW_KINDS`, `requiresScheduleOrganization`), makes the
near-miss vocabulary module-local, renames the module to
`schedule-organization.zod.ts` so its source ships and the docs generator gives
it a page, and corrects the two comments that claimed `FlowSchema` emits the
refusal sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
The fixture comment promised a refusal suite that did not exist. It exists now:
the throw itself (the F1 contract), the `error` line carrying the same sentence
the engine's audit points at, near-miss naming, the "hot re-publish removes the
key" stop() limb, per-flow isolation, the no-`error`-channel fallback, and every
limb of `resolveBindingOrganization`. The time-relative sweep gets the same two
load-bearing pins, and `packages/spec` gets the unit test every sibling module
in `src/automation` already had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…l B honest

Consequence (3)'s dogfood pin asserted only that the job service was never
asked. That says nothing about what the ENGINE recorded, which is where the
refusal was being lost: `getFlowRuntimeStates()` answered `bound: true` and
`getTriggerBindingAudit()` skipped the flow entirely. Both are now pinned, each
with the declaring flow as its paired control.

Control B's memory limb asserted `status < 300` under a message claiming it
pinned a 503 refusal — opposite polarity, so it certified nothing. It now states
plainly that the HTTP control is unavailable on this driver and pins the reason
at the seam that makes it so: a tenant-scoped read is refused with
MEMORY_MULTI_TENANT_UNSUPPORTED, so the exemption expires by itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…ished surface

The trigger-schedule README (shipped in files[]) still showed both worked
examples without `organization` and said they auto-launch; after this change
those exact flows are refused. Both now declare the key, and the README states
the refusal and where it shows up.

flows.mdx gains a dedicated "The acting organization" section and both worked
schedule examples declare the key; hooks.mdx and capabilities.mdx point at it so
"needs `triggers`" is no longer the whole story.

The showcase digest's docstring stops promising that it fires: as a
package-shipped flow it has no legal organization to name, no placeholder may be
invented, and what such a flow should do instead is #17150's decision.

Drops the unrelated esbuild@0.28.1/0.28.2 swap from pnpm-lock.yaml; the diff is
now only the link: entry the new dev-dependency needs, and
`pnpm install --frozen-lockfile` leaves it byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…t set

`schedule-organization` now has its own reference page instead of landing in
the "Misc (no single source file)" bucket, and api-surface / export-origins
drop the three consumer-less exports. The changeset's export list matches what
the barrel actually publishes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…w module

Two gate findings the .zod.ts rename brought into scope, both real:
check:spec-parsed-alias only reads *.zod.ts, so `ScheduleOrganization` was
declared with z.infer where ADR-0122 reserves the bare name for the author
state; and llms.txt counts *.zod.ts modules, so the automation domain and the
total were one short.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
`ScheduleOrganizationSchema` is a bare `z.string().min(1)` — no coercion, no
default, no transform — so input === infer and it takes a pin rather than a
permanent `ScheduleOrganizationParsed` synonym. The note records that the
module is not new, only its `.zod.ts` name is: this family of gates reads
`*.zod.ts` only, so the violation sat green behind an extension.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…anization-r2

os-regen artifacts taken from main where both sides moved them
(content/docs/references/index.mdx); the branch's own generated entries kept.
The one content conflict, packages/spec/src/type-alias-convention.pin.test.ts,
is a semantic merge of two stacking intents: main's -4 (ui#6206-B filter
orthography converting four pins to XParsed pairs, 815 -> 811) and this
branch's +1 (Iso871, ScheduleOrganizationSchema). Merged truth 812, recomputed
from the file rather than arithmetic. Regeneration follows in its own commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Discharges the os-regen deferral recorded by the merge commit. `gen:schema`
then `gen:docs` on the merged tree: totals 198/1583 -> 199/1584, automation
13/73 -> 14/74, and the `schedule-organization.zod.ts` row. Every other
generated artifact re-derived byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/lint, @objectstack/dogfood, @objectstack/service-automation, @objectstack/spec, @objectstack/trigger-schedule, touching 27 documentable anchor(s). ⚠️ 11 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/qa/dogfood/package.json, packages/qa/dogfood/tsconfig.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 31064ca0ef30ed4c03482183f96baba11c8d0a76.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 11 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/qa/dogfood/package.json, packages/qa/dogfood/tsconfig.json, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 31 pages)
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 31064ca0ef30ed4c03482183f96baba11c8d0a76packageMentionDocs.

Which tree this was computed on

This run read content/docs from e59beff040bec2069e82a7470b5028335ccd6941 — the merge of head 18c0f6b44bb9ef31cacb0c3af02d65eb1093ab99 into base 31064ca0ef30ed4c03482183f96baba11c8d0a76, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e59beff040bec2069e82a7470b5028335ccd6941 && git checkout e59beff040bec2069e82a7470b5028335ccd6941
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 31064ca0ef30ed4c03482183f96baba11c8d0a76 18c0f6b44bb9ef31cacb0c3af02d65eb1093ab99 && git checkout -B drift-repro 31064ca0ef30ed4c03482183f96baba11c8d0a76 && git merge --no-ff 18c0f6b44bb9ef31cacb0c3af02d65eb1093ab99

node scripts/docs-audit/affected-docs.mjs --json 31064ca0ef30ed4c03482183f96baba11c8d0a76

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 31064ca0ef30ed4c03482183f96baba11c8d0a76 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 10, 2026
…clared organization

The declaration reached the run and never the query. A sweep declared for org A
still matched rows in every tenant — `find` carried `context: { isSystem: true }`
and nothing else — and then launched a run stamped A about B's record: the run's
`update_record` matched nothing (silently, being scoped to A), `notify` posted
into A's inbox about B's record, and the history row was stamped from the
subject, landing under B. That is the cross-organization scheduled task the
ruling forbids, with a declaration papering over it.

The declared organization now rides the sweep's own query as
`context.tenantId`, the same ExecutionContext axis the run already uses:
`Engine.buildDriverOptions` turns it into `DriverOptions.tenantId` and the
driver scopes the read. ⛔ Not a hand-built `organization_id` predicate — that
would be a second implementation of tenancy inside a trigger, hardcoding a
renameable column, selecting nothing on a platform-global object, breaking a
federated one, and reading as scoped to a driver that never learned scoping was
wanted.

Elevation and tenancy stay independent: `isSystem` says what the sweep may see,
`tenantId` says whose rows they are.

Silence is closed on both new edges: a store that cannot honour the scope
refuses the call and the sweep now logs that at `error` (stderr survives the
CLI's boot-quiet window), and an object the engine exempts from scoping
(`tenancy.enabled: false`, `external`) gets a bind-time warning saying the
declaration cannot narrow this sweep. The bind line names the acting
organization so the sweep's reach is readable from the boot log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
The existing pin read `expect(calls[0].context).toEqual({ isSystem: true })` —
it was pinning the defect, so it is replaced rather than relaxed: exact equality
against `{ isSystem: true, tenantId: <declared> }`, which reddens the moment the
sweep asks for no scope.

Four new pins:
 - every window query carries the scope, not just the first (offset mode issues
   one per offset), and `where` still holds only the date window, so no
   organization predicate was hand-built onto it;
 - DIFFERENTIAL — matching rows in TWO organizations, only the declared one's
   record launches a run. The double implements the driver's documented scope
   (`org = :tenant OR org IS NULL`) and, with no scope, no predicate at all, so
   its "absent" arm reproduces the defect instead of hiding it;
 - a store that cannot honour the scope (driver-memory's #16589 refusal) is
   reported at `error` naming the flow, never answered unscoped;
 - a `tenancy.enabled: false` object is disclosed at bind — the declaration
   cannot narrow that sweep — with a paired control that an ordinary object
   draws no such line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
The differential control the branch never had: `sched_org_sweep`, a
`time_relative` flow declared for org A, with matching rows in BOTH A (one) and
B (two). The discriminating number is the count of LAUNCHED RUNS — 1 with the
fix, 3 on the defect — because a pin asserting only "A's row was touched"
passes on the defect too.

Three properties, three producers:
 - selection: exactly one run, and it names A's record;
 - the data plane: `update_record` lands on A's row and on no other (the half
   the branch left unpinned — the earlier fixture flow was start -> notify ->
   end, so nothing would have gone red if the run's narrowing were wrong);
 - disclosure: no `sys_notification` names a B record. An unscoped sweep emits
   that notification BEFORE its write silently matches nothing, so a fix that
   narrowed only the writes would leave the leak open.

The memory limb answers the other question the card is about: a store with no
tenant isolation REFUSES the scoped sweep (#16589) and the failure is reported
at `error` naming the flow, with a paired control proving the refusal is about
the scope and not about the fixture (the same query unscoped still sees all
three rows).

The fixture object gains `due_date` as `datetime`, not `date`: the window is a
pair of ISO instants, and a column the driver truncates to YYYY-MM-DD would put
a per-driver truncation rule between the fixture and the property under test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
`validate-flow-trigger-readiness.ts` contained zero occurrences of
`organization`, against the invariant `engine.ts`'s own trigger-kind resolver
states: it is shared with `defineStack`'s trigger-capability refusal and with
this rule file, "so the runtime cannot drift from what authoring accepted".
Both time triggers refuse to bind a flow declaring no `config.organization`, so
`defineStack`, `os lint` and `verify_build` were all passing a flow the trigger
then refused — an author's first signal was a production stderr line at boot.

⛔ No judgement is re-implemented: `resolveFlowTriggerKind` says which flows owe
the key, `resolveScheduleOrganization` says whether one was declared (so a
present-but-unusable value is judged identically here and at bind), and
`describeMissingScheduleOrganization` writes the sentence.

Severity is `warning`, and that is MEASURED rather than argued. On the family's
own criterion (#5762 — is this stack enough to know the flow is dead?) it
belongs at `error`. Flipping it to `error` and building the shipped example app
was run: `objectstack build` on examples/app-showcase FAILS, naming
`showcase_task_due_reminder` and `showcase_scheduled_digest` — and neither can
be repaired by authoring, because the only legal value is a
`sys_organization.id` minted per install at runtime and a placeholder id is
strictly worse than the omission. Promoting the id is therefore a consequence
of the open maintainer decision about package-shipped time-triggered flows, not
a lint choice; the probe was restored byte-identically and the two example
builds pass with the rule at `warning`, flagging exactly the four shipped flows.

Fixture triage in the rule's own suite: every time-triggered fixture that is
ABOUT another rule now declares an organization, so it keeps isolating its own
subject; the fixtures that are about the missing key omit it deliberately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37

os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Contract review @ 18c0f6b44b — re-review on the moved head

Verdict: PASS — zero must-fix; three advisories, none of them blocking.

  • Implemented-by: the round-3 commits f23723f564 (merge) · 323da7a30d (M1/M2/A1/A2/A4-A6) · 18c0f6b44b (changeset marker), authored from session_01MkQhmuuJAVDjmeWNixwDDH
  • Reviewed-by: CONTRACT_REVIEW_TIER subagent dispatched by the domain:spec execution seat, session_01MkQhmuuJAVDjmeWNixwDDH

⚠️ Independence, stated plainly: this reviewer was dispatched by the domain:spec seat and inherits that session id. This is a seat-side at-tier review. It is ⛔ NOT cross-seat independent — the seat that commissioned the round also commissioned this verdict on it. A reader who needs an independent verdict must not read one into this comment.

Head reviewed: 18c0f6b44bb9ef31cacb0c3af02d65eb1093ab99 — confirmed as the PR head via refs/pull/17334/head and reviewed in a private detached worktree at that sha (git status --porcelain empty after every run). Subject is the delta from 5fb332bdc (prior PASS WITH FINDINGS, comment 5619271717) plus what the delta could have broken; the bind-time half, its pins, F7 and the exports' design were passed there and not re-litigated.


1. The merge — per-path preservation, re-derived independently

Merge commit f23723f564 has parents 5fb332bdc (branch) × 93447bbad (main at merge time), merge base e669e5eee — matches the round. Risk list read from .gitattributes at the merge commit (git show f23723f564:.gitattributes): 18 merge=os-regen patterns (19 grep lines, one is the comment at :36; dark control merge=zzz-fabricated = 0).

reading result lit dark
routed paths moved, base→branch / base→main 8 / 46, intersection exactly 1: content/docs/references/index.mdx
the 45 main-only routed paths at HEAD vs 93447bbad git diff --name-only = empty (0) same over the 7 branch-only paths lists 7 fabricated api-surface/zzz-fabricated.json = 0
contested index.mdx at HEAD schedule-organization=2, Marketplace Protocol=2, Cloud Protocol=0 Automation Protocol=2 Zzz Fabricated Protocol=0
the same four markers at P1 / P2 branch: 2/0/2 · main: 0/2/0 — HEAD is the union
the arithmetic **Total** | **192** at index.mdx:36

One observation, not a finding: at the committed merge f23723f564 the contested file reads branch row = 0, Marketplace = 2 — i.e. the committed merge carried main's side wholesale (its message says so: "os-regen artifacts taken from main; regeneration follows") and the branch row came back through regeneration in 323da7a30d. The body's narrative describes the driver's pre-commit output dropping main's side; I cannot verify that from the commits and do not need to — the landing head carries both sides, which is the claim that matters.

Non-generated conflicts:

  • packages/spec/llms.txt## 3. Schema Inventory by Domain (199 schemas); the table rows sum 33+31+30+30+18+14+14+11+5+4+4+3+1+1 = 199 ✓.
  • type-alias-convention.pin.test.tsgrep -oP 'export type Iso\d+' | sort | uniq -d = 0 duplicates; Iso871 = main's EnvironmentTypeSchema (:388), Iso872 = this branch's ScheduleOrganizationSchema (:637). Its own predicate: "still declares all 783 isomorphic pins" passes (vitest, --project local). ⭐ Because a duplicate identifier is a type error vitest never sees, I also ran tsc --noEmit on @objectstack/spec: exit 0, zero error TS.

2. Clause ② — yes, and the delta is exactly what the body claims

  • git diff origin/main...HEAD under api-surface/ export-origins/ declaration-map/ api-surface-signatures.json touches only the three automation.json files.
  • api-surface/automation.json delta: +5 rows, −0SCHEDULE_ORGANIZATION_KEY (const), ScheduleOrganization (type), ScheduleOrganizationSchema (const), describeMissingScheduleOrganization (function), resolveScheduleOrganization (function). export-origins: the same five, all to schedule-organization.zod.ts. declaration-map: +2 (the two type-bearing names — that map carries declarations, not consts/functions, so 2 is the right count there, not a discrepancy).
  • findScheduleOrganizationNearMissInConfig: 0 in all three surfaces and 0 under content/; the one remaining occurrence is the now-unexported function at schedule-organization.zod.ts:193 (lit: describeMissingScheduleOrganization = 1 in api-surface; dark: ZzzFabricatedSymbol = 0).
  • @objectstack/lint: FLOW_SCHEDULE_ORGANIZATION_MISSING re-exported at packages/lint/src/index.ts:123.
  • node scripts/pm/check-clause2-carriers.mjs --pair 17334exit 0, declaration readable in the fixed spelling, both carriers agree.
  • Level: minor on all four packages; the ! narrowing rides minor under the launch-window convention the changeset itself names, and the ADR-0087 disposition is the carrier. ⇒ five + one published symbols, minor, declared yes — correct.

3. M1 — protocol 18 is right, and the entry is a real D3 prescription

  • registry.ts:5062 (the step18 doc header) states the convention verbatim — "v17.0.0 was cut before these narrowings landed, so their migration prescriptions belong to the NEXT major"; @objectstack/spec is at 17.4.0; the sibling 18.cloud-subpath-retired.ts sits in the same directory. The dispatch's "17" was wrong; the tree is the authority. 18.
  • The entry (18.schedule-flow-acting-organization-required.ts) carries all four D3 limbs with content, not placeholders: surface names the start-node key and both trigger kinds; replacement is the actionable prescription (declare per organization, no fan-out, no fallback) plus the three split consequences; reason quotes the ruling untranslated and states why no conversion is possible; acceptanceCriteria is checkable (lint id absent, no NOT BOUND line, bound: true, audit empty).
  • Wired: registry.ts:9393 inside step18 (509210457); check:migration-registry exit 0 — "registry.ts is current (198 semantic …)".
  • node scripts/check-adr-0087-registration.mjs --base origin/main, run post-commit in the worktree: exit 0, registered schedule-flow-acting-organization-required (new here: …).
  • The marker fix in 18c0f6b44b is real: at 323da7a30d the <!-- adr-0087: registered … --> remainder was a paragraph of prose; at HEAD it is the bare id.

4. M2 — the banner now tells the truth, and stopping at the fence was right

  • memory-tenancy-guard.ts:313if (tenantId === undefined && tenantIds.length === 0) return; and a throw otherwise; no organization-count limb. assertCallNotTenantScoped( is called from 14 lines of memory-driver.ts. The banner's driver-conditional sentence names exactly this guard and this code and no longer claims a single-organization install "loses nothing".
  • The functional gap is real on the tree as read. It is a design decision (exempt time-triggered flows on a store without isolation, or declare driver-memory unsupported for them) and outside the fence; the round named it in the banner and the migration entry's acceptanceCriteria rather than implying it away. Right call. It is now tracked as On driver-memory a time-triggered flow that touches per-organization data has NO legal configuration — PR #17334 moves it from the served case into the refused one #17446 (filed 14:51Z — see ADVISORY A-2).

5. A1 — the fold is a pure move

323da7a30d diff: export functionfunction on the finder; describeMissingScheduleOrganization(flowName, { kind, nearMiss }){ kind, config }, computing the near-miss inside. Both callers pass the same value they previously fed the finder (binding.config at schedule-trigger.ts:333; config at validate-flow-trigger-readiness.ts:733). The near-miss vocabulary is still exercised through the sentence: schedule-organization.test.ts describes "the near-miss scan, through the sentence that owns it" (10 near-miss spelling hits), lint suite 3 hits; no test anywhere references the folded name (0 files).

6. A2 — the measurement holds

flow.zod.ts:729: type: z.enum(['autolaunched', 'record_change', 'schedule', 'screen', 'api']); time_relative reads 0 in that file. content/docs/automation/flows.mdx:1926,1966 set type: 'schedule'. ADR corpus: time_relative|timeRelative = 0 files (lit \bschedule\b = 18 files, dark zzzFabricatedTerm = 0). ⇒ every flow the key applies to is authored type: 'schedule'; keeping ScheduleOrganization… is the measured outcome, recorded in the docblock and the reference page.

7. A4 / A5 / A6 — each anchored to a line

  • A4 sql-driver.ts:12911(field = :tenantId OR field IS NULL) with .orWhereNull(field) above it; fixture time-relative-trigger.test.ts:937 has { id: 'c3', …, organization_id: null } and :965 asserts ['c1', 'c2', 'c3'] under scope. The N-fold fan-out follows and is now item (1) in the banner and the entry.
  • A5 schedule-trigger.ts:225 schedule:${flowName}:${window.startedAt}; time-relative-trigger.ts:524 time-relative:${flowName}:${scope}:${id}. Item (2).
  • A6 suspended-run-store.ts:930 parseJson(row.context_json, {}), nothing back-fills. Item (3).

8. What the delta could have broken — suites on the built worktree

run result
@objectstack/trigger-schedule test exit 0 — 5 files / 105 passed
@objectstack/lint test exit 0 — 103 files / 3724 passed
@objectstack/spec pin + schedule-organization tests exit 0 — 31 passed
@objectstack/spec full suite 468/470 files, 13229 / 13238 passed — the 9 failures are in scripts/openapi-self-consistency.test.ts and scripts/def-key-collisions.test.ts, both untouched by this PR (diff = 0; lit: the pin test = 1), both spawning npx tsx from a temp dir that resolved to the absent /tmp/node_modules/… because my worktree is /tmp-rooted. ⇒ NOT MEASURED here (prerequisite), not red; the round reports 13238 at this head and CI is the authority
tsc --noEmit — spec / lint / trigger-schedule exit 0 all three
pnpm --filter @objectstack/spec check:generated exit 0 — 15 of 15 current. ⚠️ On the unbuilt worktree it first read api-surface/ stale (exit 1); check-generated.ts:113 marks that gate readsDist: true, so that was a prerequisite miss, and it cleared on the built tree
check:migration-registry exit 0
os-verify-lock --status before the heavy runs exit 0, lock free

Findings

MUST-FIX — none.

ADVISORY A-1 — the merge narrative vs the committed merge. The body says the driver's output carried the branch row and lost main's Marketplace section; the committed f23723f564 carries main's side and not the branch row. Both are consistent with the commit message ("taken from main; regeneration follows"), and the landing head is correct. Worth one sentence in the body so the next reader of this merge does not see a contradiction — no code change.

ADVISORY A-2 — #17446 is not named by the artifacts that describe its gap. The driver-memory functional gap now has a card (#17446, 14:51Z), filed after 18c0f6b44b (last changeset edit). The banner and the migration entry's acceptanceCriteria describe the gap but reference only #16589. Not this round's to fix and not blocking; the seat may want to put the number where the gap is described, or leave it to the card.

ADVISORY A-3 — declaration-map carries 2 of the 5 names. By design (it maps type declarations to pages), so this is not a surface discrepancy — recorded so a future count against that file is not misread as a missing export.

Reading of the round's own report

Every number I re-derived matched: 8/46/1 routed paths, 45 byte-identical, 192, 199, 783, 0 duplicate ids, 5 rows, check:generated 15/15 on a built tree, 105 / 3724. The one difference (spec full suite 13229 vs 13238) is my environment, shown above with its cause and controls.

⛔ This comment changes no label, flips nothing ready, enqueues nothing — those are the seat's acts.


Generated by Claude Code

@os-bill
os-bill marked this pull request as ready for review September 10, 2026 15:17
@os-bill
os-bill enabled auto-merge September 10, 2026 15:17

os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

ACCEPT — at-tier PASS on the head that lands, gate cleared on both carriers, undrafted and enqueued

domain:spec execution seat, session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-10T15:17Z. Landing under the maintainer's direct instruction to carry this PR to merge.

The four landing pre-checks, on head 18c0f6b44b

# pre-check reading
at-tier verdict for the head that lands PASS, zero must-fix, three advisories — 5620983965
check-clause2-carriers --pair 17334 exit 0, both carriers agree — re-run after the clear, still 0
check-governed-merges --test over the 44 final paths 0 governed · lit control AGENTS.md exit 3 ⇒ the instrument fires
CI newest-run-per-check-NAME plus the combined commit status 41 runs, all completed: 0 failure, 0 pending · combined status success

⇒ ③ settles the merge route: ⛔ not governed, so this is an ordinary queue landing and ⛔ not a hand merge.

The verdict was RE-TAKEN, not inherited

The earlier review (5619271717) returned PASS WITH FINDINGS on 5fb332bdc. That head then moved a long way — a full origin/main merge, M1, M2, the A1 fold, and the Iso872 renumber — so its verdict did not transfer. ⭐ A verdict on a head that no longer exists is not a verdict on this one. The re-review measured the delta in its own worktree at 18c0f6b44b.

⚠️ Declared, because it would be wrong to let a reader infer otherwise: the re-review is seat-side at-tier, ⛔ not cross-seat independent — the reviewing subagent was dispatched by this seat and inherits its session id. The reviewer says so in its own comment.

What it independently re-derived rather than accepting: the risk list from .gitattributes at the merge commit (18 patterns); 8 branch / 46 main / exactly 1 contested path; the 45 main-only routed paths byte-identical to main-at-merge (0 diffs, lit 7, dark 0); the contested file carrying both sides and totalling 192; llms.txt summing to 199; the pin test at 783 with 0 duplicate Iso ids and tsc --noEmit exit 0 — ⭐ that last one matters because the duplicate-identifier failure mode is a type error vitest cannot see, so the suite passing would not have caught it; the api-surface delta at exactly +5 rows with the folded symbol reading 0 everywhere; and protocol 18 confirmed from registry.ts:5062's own header plus the sibling 18.cloud-subpath-retired.

⭐ It also refused to report a red it could not attribute: the spec suite showed 9 failures in two files this PR does not touch, traced to its own /tmp-rooted worktree resolving npx tsx against an absent /tmp/node_modules — reported as NOT MEASURED, not as a failure of this PR.

One advisory worth reading, since it corrects this PR's own narrative

The body says the merge=os-regen driver dropped main's side on content/docs/references/index.mdx and that step 2 restored it. The reviewer measured the committed merge and found the opposite pre-commit state — the commit carried main's side and had lost the branch row, which regeneration then restored. ⇒ ⭐ The outcome is correct either way (the file at HEAD carries both sides, verified), and the operational lesson is unchanged and still the important one: the driver exited 0 while a side was missing, with no conflict marker. Only the direction in the narrative is backwards. Recorded here rather than by editing the body — a REST PATCH /pulls appends a second attribution footer.

The other two advisories: #17446 is not yet cited at the place in the changeset where the driver-memory gap is described, and the declaration-map delta is +2 (types only) rather than +5, which is by design.

M2 — the gap is filed, and the dedup changed where it goes

The functional gap M2 uncovered — on driver-memory a time-triggered flow touching per-organization data has no legal configuration — is filed as #17446. ⭐ ⛔ It did not go into the decision box: a dedup search found #5499, the maintainer's standing 2026-08-05 ruling freezing the driver-memory / driver-mongodb family, whose direction is replacement by sqlite :memory: and whose standing triage rule sends new cards of that family to pm:on-hold referencing it, ⛔ not to pm:queue. The round's recommended option A is that ruling, for the whole component. I also checked #5499's exception-escalation channel (a defect affecting CI's green correctness overrides the freeze) — ⛔ it does not apply: this PR's own dogfood test runs the memory leg and asserts the refusal, so the semantics are pinned, no false green.

Both gate carriers cleared in ONE stroke, deliberately

needs:contract-review removed from card #16659 and from this PR, seconds apart, each read back. ⭐ Two removals close together is what a legitimate clear looks like in the event stream; a lone removal is indistinguishable from a strip, and 「闸门标签被剥不是红灯是放行」. I broke exactly this on #16929 earlier today by writing a whole label set, so both writes here were four-step: read the current set → remove only the target → write the union → read back the diff. Card #16659 read back as bug, priority:p1, pm:dispatched, domain:services — nothing else moved, and its domain:services lane label is untouched: ⛔ this seat took the card by handover, it does not re-lane it.

Undrafted and auto-merge armed. The merge queue decides the method; ⛔ this seat does not merge its own PR and does not bypass the queue.


Generated by Claude Code

@os-bill
os-bill added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit ecdfc94 Sep 10, 2026
46 checks passed
@os-bill
os-bill deleted the claude/issue-16659-schedule-trigger-acting-organization-r2 branch September 10, 2026 15:47
os-sam pushed a commit that referenced this pull request Sep 10, 2026
…lean`

`packages/qa/dogfood/test/fixtures/schedule-organization-fixture.ts` declared
`touched: { type: 'checkbox' }`. `checkbox` is not a `FieldType` member — the
enum spells the multi-value checkbox GROUP `checkboxes` and the scalar flag
`boolean` — so this PR's registration door refuses the whole `sched_org_target`
declaration and both #16659 pins fail at `bootStack` on `[sqlite-wasm]` and
`[memory]`.

Neither side is wrong alone. The declaration landed in ecdfc94 (#17334),
after this branch's merge base `d57611dfd3`, where nothing refused it; the door
is this card's deliverable. The merge queue tests the PR merged into current
`main`, which is the first place the two meet — so this is the merge, not a
defect in either diff.

`boolean` and not `checkboxes`: the fixture's `update_record` node writes
`touched: true`, and both pins read it back as a scalar
(`r.touched === true || r.touched === 1`, `Boolean(r.touched)`). `checkboxes`
stores a JSON array of option values, whose EMPTY set is truthy — the sweep
pin's differential would then report rows nothing touched as touched. The
member is chosen from the enum's own grouping and from what the tests assert,
not from the refusal message's Levenshtein suggestion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

3 participants